iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0

寫到一半才意識到要畫寫實的雲前,需要先有現實的物理演算才能算出對應的效果。而前面基本上都沒介紹這部分,在這裡先帶過。

雖然前面有太陽的 shader,但其實那個只是單純的看起來有那效果而已,而這裡就是要更徹底的把相關的數學帶進來,而首先要介紹的就是 PBR。

PBR是什麼

PBR 是 Physically Based Rendering 的簡稱,直翻就是物理基礎渲染。因為視覺效果呈現可以有非常多種的方式,依照古典物理來去繪出整個世界的樣子是其中一種方法。而這次因為想要畫出逼真的雲的效果,所以以此為基礎。

應該在高中時期上物理課時蠻常看見這樣的圖,光從甚麼角度打到物體就從甚麼角度反射回去。

其中打中物體後又會有間接光在物體間反射,而這也是之後雲會用到的相關知識之一。

其中在遊戲裡常見的光的實作方式有三種

  • Direction Light
  • Point Light
  • Spot Light

不過這裡主要集中 Direction Light,也就是太陽光的部分。

實作

//light.hlsl

// 定義圓周率常數供光照公式使用
static const float PI = 3.14159265359f;

cbuffer PbrConstants : register(b0)
{
    // 物件從 Local Space 轉換到 World Space 的矩陣
    row_major float4x4 world;

    // 物件從 Local Space 轉換到 Clip Space 的 World View Projection 矩陣
    row_major float4x4 worldViewProjection;

    // 相機在 World Space 中的位置
    float3 cameraPosition;

    // 用來符合 Constant Buffer 記憶體對齊需求的填充值
    float padding0;

    // 材質的基礎顏色
    float3 baseColor;

    // 材質的金屬度
    float metallic;

    // 太陽光在 World Space 中的方向
    float3 sunDirection;

    // 材質的粗糙度
    float roughness;

    // 太陽光顏色
    float3 sunColor;

    // 太陽光強度
    float sunIntensity;
};

struct VSInput
{
    // 頂點在 Local Space 中的位置
    float3 position : POSITION;

    // 頂點在 Local Space 中的法線
    float3 normal : NORMAL;

    // 頂點的 UV 座標
    float2 uv : TEXCOORD;
};

struct VSOutput
{
    // 頂點在 Clip Space 中的位置
    float4 position : SV_POSITION;

    // 頂點在 World Space 中的位置
    float3 worldPosition : POSITION0;

    // 頂點在 World Space 中的法線
    float3 worldNormal : NORMAL;
};

// Vertex Shader 主函式
VSOutput VSMain(VSInput input)
{
    // 建立 Vertex Shader 的輸出資料
    VSOutput output;

    // 將頂點位置從 Local Space 轉換到 World Space
    const float4 worldPosition = mul(float4(input.position, 1.0f), world);

    // 將頂點位置轉換到 Clip Space
    output.position = mul(float4(input.position, 1.0f), worldViewProjection);

    // 儲存 World Space 頂點位置供 Pixel Shader 計算光照
    output.worldPosition = worldPosition.xyz;

    // 將法線轉換到 World Space 並正規化
    output.worldNormal = normalize(mul(input.normal, (float3x3) world));

    // 回傳轉換完成的頂點資料
    return output;
}

// 計算 GGX Normal Distribution Function
float DistributionGGX(float3 normal, float3 halfVector, float alpha)
{
    // 計算 alpha 的平方
    const float alphaSquared = alpha * alpha;

    // 計算法線與 Half Vector 的內積並限制在 0 到 1
    const float normalDotHalf = saturate(dot(normal, halfVector));

    // 計算 GGX Distribution 的分母中間項
    const float denominator = normalDotHalf * normalDotHalf * (alphaSquared - 1.0f) + 1.0f;

    // 回傳 GGX Normal Distribution 並避免分母過小
    return alphaSquared / max(PI * denominator * denominator, 0.0001f);
}

// 使用 Schlick-GGX 近似計算單一方向的 Geometry Term
float GeometrySchlickGGX(float normalDotDirection, float roughnessValue)
{
    // 根據粗糙度計算 Schlick-GGX 使用的 k 參數
    const float k = ((roughnessValue + 1.0f) * (roughnessValue + 1.0f)) / 8.0f;

    // 回傳 Geometry Term 並避免分母過小
    return normalDotDirection / max(normalDotDirection * (1.0f - k) + k, 0.0001f);
}

// 使用 Schlick Approximation 計算 Fresnel 反射率
float3 FresnelSchlick(float cosine, float3 reflectance)
{
    // 根據入射角度從基礎反射率逼近 Fresnel Effect
    return reflectance + (1.0f - reflectance) * pow(1.0f - saturate(cosine), 5.0f);
}

// Pixel Shader 主函式
float4 PSMain(VSOutput input) : SV_TARGET
{
    // 正規化插值後的 World Space 法線
    const float3 normal = normalize(input.worldNormal);

    // 計算表面指向相機的 View Direction
    const float3 viewDirection = normalize(cameraPosition - input.worldPosition);

    // 正規化光源方向
    const float3 lightDirection = normalize(sunDirection);

    // 計算 View Direction 與 Light Direction 中間的 Half Vector
    const float3 halfVector = normalize(viewDirection + lightDirection);

    // 計算法線與光源方向的內積
    const float normalDotLight = saturate(dot(normal, lightDirection));

    // 計算法線與視線方向的內積
    const float normalDotView = saturate(dot(normal, viewDirection));

    // 根據金屬度在非金屬預設反射率與 Base Color 之間插值
    const float3 f0 = lerp(0.04f.xxx, baseColor, metallic);

    // 將 Roughness 平方轉換為 GGX 使用的 alpha 並設定最小值
    const float alpha = max(roughness * roughness, 0.0025f);

    // 計算 GGX Normal Distribution
    const float distribution = DistributionGGX(normal, halfVector, alpha);

    // 分別計算 View 與 Light 方向的 Geometry Term 後相乘
    const float geometry = GeometrySchlickGGX(normalDotView, roughness) *
                           GeometrySchlickGGX(normalDotLight, roughness);

    // 計算目前角度下的 Fresnel 反射率
    const float3 fresnel = FresnelSchlick(dot(halfVector, viewDirection), f0);

    // 使用 Cook-Torrance BRDF 計算 Specular Reflection
    const float3 specular = distribution * geometry * fresnel /
                            max(4.0f * normalDotView * normalDotLight, 0.0001f);

    // 計算 Diffuse Reflection 所占比例並排除金屬材質的 Diffuse 成分
    const float3 diffuseWeight = (1.0f - fresnel) * (1.0f - metallic);

    // 根據光源顏色與強度計算入射 Radiance
    const float3 radiance = sunColor * sunIntensity;

    // 結合 Lambert Diffuse 與 Cook-Torrance Specular 計算直接光照
    const float3 directLighting = (diffuseWeight * baseColor / PI + specular) * radiance * normalDotLight;

    // 加入簡單的環境光並降低金屬材質的環境 Diffuse
    const float3 ambient = baseColor * (0.025f * (1.0f - metallic));

    // 合併直接光照與環境光得到 HDR 顏色
    const float3 mappedColor = directLighting + ambient;

    // 使用 Reinhard Tone Mapping 將 HDR 顏色映射到較低動態範圍
    const float3 toneMapped = mappedColor / (mappedColor + 1.0f);

    // 執行 Gamma Correction 後輸出不透明顏色
    return float4(pow(toneMapped, 1.0f / 2.2f), 1.0f);
}
//main.cpp

#include <array>
#include <cstdlib>
#include <exception>
#include <filesystem>
#include <stdexcept>
#include <string>

#include <DirectXMath.h>
#include <directx/d3dx12_core.h>

#include "constant_buffer.h"
#include "graphics_engine.h"
#include "mesh.h"
#include "pipeline_state.h"
#include "render_context.h"
#include "render_pass.h"
#include "root_signature.h"
#include "shader.h"
#include "skyline_debugger.h"
#include "system.h"

namespace
{
using namespace DirectX;

constexpr XMFLOAT3 cameraPosition{0.0f, 4.0f, -10.0f};
constexpr XMFLOAT3 cameraTarget{0.0f, 1.0f, 0.0f};
constexpr XMFLOAT3 sunDirection{0.45f, 0.72f, 0.53f};
constexpr XMFLOAT3 sunColor{1.0f, 0.91f, 0.78f};
constexpr float sunIntensity = 5.0f;

struct SunConstants
{
    XMFLOAT4X4 inverseView;
    XMFLOAT4X4 inverseProjection;
    XMFLOAT3 direction;
    float radius;
    XMFLOAT3 color;
    float intensity;
};

struct PbrConstants
{
    XMFLOAT4X4 world;
    XMFLOAT4X4 worldViewProjection;
    XMFLOAT3 camera;
    float padding0;
    XMFLOAT3 baseColor;
    float metallic;
    XMFLOAT3 direction;
    float roughness;
    XMFLOAT3 lightColor;
    float lightIntensity;
};

static_assert(sizeof(SunConstants) == 160);
static_assert(sizeof(PbrConstants) == 192);

std::filesystem::path getExecutableDirectory()
{
    wchar_t executablePath[MAX_PATH]{};
    const DWORD pathLength = GetModuleFileNameW(nullptr, executablePath, _countof(executablePath));
    if (pathLength == 0 || pathLength == _countof(executablePath))
        throw std::runtime_error("DayX_PBR: Failed to determine the executable directory.");
    return std::filesystem::path(executablePath).parent_path();
}

XMMATRIX createViewMatrix()
{
    return XMMatrixLookAtLH(XMLoadFloat3(&cameraPosition), XMLoadFloat3(&cameraTarget), XMVectorSet(0, 1, 0, 0));
}

XMMATRIX createProjectionMatrix(UINT width, UINT height)
{
    if (width == 0 || height == 0)
        throw std::runtime_error("DayX_PBR: Frame-buffer dimensions must be non-zero.");
    return XMMatrixPerspectiveFovLH(XMConvertToRadians(55.0f), static_cast<float>(width) / height, 0.1f, 100.0f);
}

D3D12_GRAPHICS_PIPELINE_STATE_DESC createPipelineDescription(ID3D12RootSignature* rootSignature,
                                                              ID3DBlob* vertexShader, ID3DBlob* pixelShader,
                                                              bool meshPipeline)
{
    if (rootSignature == nullptr || vertexShader == nullptr || pixelShader == nullptr)
        throw std::invalid_argument("DayX_PBR: Pipeline shaders and root signature are required.");

    static constexpr D3D12_INPUT_ELEMENT_DESC inputLayout[] = {
        {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0},
        {"NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0},
        {"TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 24, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0},
    };

    D3D12_GRAPHICS_PIPELINE_STATE_DESC description{};
    if (meshPipeline)
        description.InputLayout = {inputLayout, _countof(inputLayout)};
    description.pRootSignature = rootSignature;
    description.VS = CD3DX12_SHADER_BYTECODE(vertexShader);
    description.PS = CD3DX12_SHADER_BYTECODE(pixelShader);
    description.RasterizerState = CD3DX12_RASTERIZER_DESC(D3D12_DEFAULT);
    description.RasterizerState.CullMode = D3D12_CULL_MODE_NONE;
    description.BlendState = CD3DX12_BLEND_DESC(D3D12_DEFAULT);
    if (!meshPipeline)
    {
        D3D12_RENDER_TARGET_BLEND_DESC& renderTarget = description.BlendState.RenderTarget[0];
        renderTarget.BlendEnable = TRUE;
        renderTarget.SrcBlend = D3D12_BLEND_SRC_ALPHA;
        renderTarget.DestBlend = D3D12_BLEND_INV_SRC_ALPHA;
        renderTarget.BlendOp = D3D12_BLEND_OP_ADD;
        renderTarget.SrcBlendAlpha = D3D12_BLEND_ONE;
        renderTarget.DestBlendAlpha = D3D12_BLEND_ZERO;
        renderTarget.BlendOpAlpha = D3D12_BLEND_OP_ADD;
        renderTarget.RenderTargetWriteMask = D3D12_COLOR_WRITE_ENABLE_ALL;
    }
    description.DepthStencilState = CD3DX12_DEPTH_STENCIL_DESC(D3D12_DEFAULT);
    description.DepthStencilState.DepthEnable = meshPipeline ? TRUE : FALSE;
    description.DepthStencilState.DepthWriteMask = meshPipeline ? D3D12_DEPTH_WRITE_MASK_ALL : D3D12_DEPTH_WRITE_MASK_ZERO;
    description.DepthStencilState.StencilEnable = FALSE;
    description.SampleMask = UINT_MAX;
    description.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE;
    description.NumRenderTargets = 1;
    description.RTVFormats[0] = DXGI_FORMAT_R8G8B8A8_UNORM;
    description.DSVFormat = DXGI_FORMAT_D32_FLOAT;
    description.SampleDesc.Count = 1;
    return description;
}

class SunPass final : public RenderPass
{
  public:
    void init(const RenderPassInitContext& context) override
    {
        m_vertexShader.loadVS("assets/shaders/sun.hlsl", "VSMain");
        m_pixelShader.loadPS("assets/shaders/sun.hlsl", "PSMain");
        m_pipelineState.init(createPipelineDescription(context.rootSignature.get(), m_vertexShader.getCompiledBlob(),
                                                       m_pixelShader.getCompiledBlob(), false));
        updateConstants(context.graphicsEngine.getFrameBufferWidth(), context.graphicsEngine.GetFrameBufferHeight());
        m_constantBuffer.init(sizeof(m_constants), &m_constants);
    }

    void execute(const RenderFrameContext& context) override
    {
        updateConstants(context.width, context.height);
        m_constantBuffer.copyToVRAM(m_constants);
        context.renderContext.setPipelineState(m_pipelineState);
        context.renderContext.setGraphicsRootConstantBufferView(0, m_constantBuffer.getGPUVirtualAddress());
        context.renderContext.draw(3);
    }

  private:
    void updateConstants(UINT width, UINT height)
    {
        const XMMATRIX inverseView = XMMatrixInverse(nullptr, createViewMatrix());
        const XMMATRIX inverseProjection = XMMatrixInverse(nullptr, createProjectionMatrix(width, height));
        XMStoreFloat4x4(&m_constants.inverseView, inverseView);
        XMStoreFloat4x4(&m_constants.inverseProjection, inverseProjection);
        m_constants.direction = sunDirection;
        m_constants.radius = 0.035f;
        m_constants.color = sunColor;
        m_constants.intensity = 1.5f;
    }

    Shader m_vertexShader;
    Shader m_pixelShader;
    PipelineState m_pipelineState;
    ConstantBuffer m_constantBuffer;
    SunConstants m_constants{};
};

class PbrPass final : public RenderPass
{
  public:
    void init(const RenderPassInitContext& context) override
    {
        //使用 light.hlsl
        m_vertexShader.loadVS("assets/shaders/light.hlsl", "VSMain");
        m_pixelShader.loadPS("assets/shaders/light.hlsl", "PSMain");
        m_pipelineState.init(createPipelineDescription(context.rootSignature.get(), m_vertexShader.getCompiledBlob(),
                                                       m_pixelShader.getCompiledBlob(), true));
        //使用導入的幾個 fbx 檔
        const std::filesystem::path assetDirectory = getExecutableDirectory() / "assets" / "fbx";
        m_meshes[0].initFromFbxFile((assetDirectory / "Plane.fbx").string());
        m_meshes[1].initFromFbxFile((assetDirectory / "Cube.fbx").string());
        m_meshes[2].initFromFbxFile((assetDirectory / "Sphere.fbx").string());

        updateConstants(context.graphicsEngine.getFrameBufferWidth(), context.graphicsEngine.GetFrameBufferHeight());
        for (std::size_t index = 0; index < m_buffers.size(); ++index)
            m_buffers[index].init(sizeof(PbrConstants), &m_constants[index]);
    }

    void execute(const RenderFrameContext& context) override
    {
        updateConstants(context.width, context.height);
        context.renderContext.setPipelineState(m_pipelineState);
        for (std::size_t index = 0; index < m_meshes.size(); ++index)
        {
            m_buffers[index].copyToVRAM(m_constants[index]);
            context.renderContext.setGraphicsRootConstantBufferView(0, m_buffers[index].getGPUVirtualAddress());
            m_meshes[index].draw(context.renderContext);
        }
    }

  private:
    void updateConstants(UINT width, UINT height)
    {
        const XMMATRIX viewProjection = createViewMatrix() * createProjectionMatrix(width, height);
        const std::array<XMMATRIX, 3> worlds = {
            XMMatrixScaling(6.0f, 1.0f, 6.0f),
            XMMatrixRotationY(XMConvertToRadians(-20.0f)) * XMMatrixTranslation(-1.7f, 1.0f, 0.0f),
            XMMatrixTranslation(1.7f, 1.0f, 0.0f),
        };
        constexpr std::array<XMFLOAT3, 3> colors = {
            XMFLOAT3{0.38f, 0.42f, 0.46f}, XMFLOAT3{0.92f, 0.24f, 0.08f}, XMFLOAT3{0.95f, 0.71f, 0.20f}};
        constexpr std::array<float, 3> metallic = {0.0f, 0.15f, 1.0f};
        constexpr std::array<float, 3> roughness = {0.72f, 0.30f, 0.16f};

        for (std::size_t index = 0; index < m_constants.size(); ++index)
        {
            XMStoreFloat4x4(&m_constants[index].world, worlds[index]);
            XMStoreFloat4x4(&m_constants[index].worldViewProjection, worlds[index] * viewProjection);
            m_constants[index].camera = cameraPosition;
            m_constants[index].baseColor = colors[index];
            m_constants[index].metallic = metallic[index];
            m_constants[index].direction = sunDirection;
            m_constants[index].roughness = roughness[index];
            m_constants[index].lightColor = sunColor;
            m_constants[index].lightIntensity = sunIntensity;
        }
    }

    Shader m_vertexShader;
    Shader m_pixelShader;
    PipelineState m_pipelineState;
    std::array<Mesh, 3> m_meshes;
    std::array<ConstantBuffer, 3> m_buffers;
    std::array<PbrConstants, 3> m_constants{};
};
} // namespace

int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR commandLine, int showCommand)
{
    try
    {
        SkylineDebugger::Initialize("DayX_PBR");

        SKYLINE_LOG(INFO) << "[DayX_PBR] Initializing window...";
        initWindow(hInstance, hPrevInstance, commandLine, showCommand, TEXT("DayX PBR"));
        if (g_hWnd == nullptr)
            throw std::runtime_error("DayX_PBR: Failed to create the application window.");

        SKYLINE_LOG(INFO) << "[DayX_PBR] Initializing graphics engine...";
        GraphicsEngine graphicsEngine;
        graphicsEngine.init(g_hWnd, FRAME_BUFFER_W, FRAME_BUFFER_H);
        SkylineDebugger::ConfigureD3D12(graphicsEngine.getD3DDevice());

        SKYLINE_LOG(INFO) << "[DayX_PBR] Initializing render passes...";
        RootSignature rootSignature;
        rootSignature.init(D3D12_FILTER_MIN_MAG_MIP_LINEAR, D3D12_TEXTURE_ADDRESS_MODE_WRAP,
                           D3D12_TEXTURE_ADDRESS_MODE_WRAP, D3D12_TEXTURE_ADDRESS_MODE_WRAP);
        RenderPassInitContext initContext{graphicsEngine, rootSignature};
        SunPass sunPass;
        PbrPass pbrPass;
        sunPass.init(initContext);
        pbrPass.init(initContext);

        SKYLINE_LOG(INFO) << "[DayX_PBR] Initialization completed.";
        RenderContext& renderContext = graphicsEngine.getRenderContext();
        bool isFirstFrame = true;
        while (dispatchWindowMessage())
        {
            if (isFirstFrame)
                SKYLINE_LOG(INFO) << "[DayX_PBR] Rendering first frame...";

            graphicsEngine.beginRender();
            renderContext.setRootSignature(rootSignature);
            renderContext.setPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
            RenderFrameContext frameContext{renderContext, graphicsEngine, graphicsEngine.getFrameBufferWidth(),
                                            graphicsEngine.GetFrameBufferHeight()};
            sunPass.execute(frameContext);
            pbrPass.execute(frameContext);
            graphicsEngine.endRender();

            SkylineDebugger::LogD3D12Messages(graphicsEngine.getD3DDevice());
            if (isFirstFrame)
            {
                SKYLINE_LOG(INFO) << "[DayX_PBR] First frame presented.";
                isFirstFrame = false;
            }
        }

        SkylineDebugger::Shutdown();
        return EXIT_SUCCESS;
    }
    catch (const std::exception& exception)
    {
        SkylineDebugger::ShowFatalError("DayX_PBR initialization failed", exception);
        SkylineDebugger::Shutdown();
        return EXIT_FAILURE;
    }
    catch (...)
    {
        SkylineDebugger::ShowFatalError("DayX_PBR initialization failed", "An unknown fatal error occurred.");
        SkylineDebugger::Shutdown();
        return EXIT_FAILURE;
    }
}

結果

參考資料

An Introduction to Physically Based Rendering | Maxime Garcia


上一篇
Day 22:放入 fbx
系列文
因為 AI 看不懂老舊程式,只好乖乖從零開始學 DirectX 12 與 HLSL23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言